Skip to content

FEAT: Capture API response stop reason on MessagePiece metadata - #2340

Merged
varunj-msft merged 4 commits into
microsoft:mainfrom
varunj-msft:varunj-msft/10481-Capture-API-Response-Metadata
Aug 12, 2026
Merged

FEAT: Capture API response stop reason on MessagePiece metadata#2340
varunj-msft merged 4 commits into
microsoft:mainfrom
varunj-msft:varunj-msft/10481-Capture-API-Response-Metadata

Conversation

@varunj-msft

@varunj-msft varunj-msft commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Description

Targets already recorded token usage but discarded why generation stopped, and dropped
usage entirely on content-filtered responses. This captures the provider's stop reason
alongside token usage on MessagePiece.prompt_metadata:

  • finish_reason — Chat Completions, Completions, LiteLLM
  • status + incomplete_reason — Responses API

A base no-op hook OpenAITarget._capture_response_metadata is called from
_handle_content_filter_response, so a filtered response now records the tokens it consumed.

These keys are reserved for the provider. construct_response_from_request merges the
request's prompt_metadata into every response piece, so a caller-supplied finish_reason
would otherwise be indistinguishable from the real one. All reserved keys are cleared from
every piece before each capture writes back what its own API reported; token_usage_* is
cleared by prefix for the same reason. set_response_metadata takes one keyword-only
parameter per reserved key, so an unrecognized key is a TypeError at the call site rather
than a value that is silently dropped.

Two bugs fixed along the way:

  • OpenAICompletionTarget captured neither usage nor finish_reason.
  • With n>1, each piece now gets its own choice's finish_reason rather than sharing one.

Known gap: the HTTP 400 path builds its response without going
through the capture helpers, so a caller-supplied reserved key still survives there. That
path is unchanged by this PR and behaves as it does on main today.

Not breaking: _METADATA_PREFIXTOKEN_USAGE_METADATA_PREFIX was private with no
external callers.

Tests and Documentation

  • Full tests/unit: 15,021 passed / 0 failed. Diff coverage 95% (gate is 90%).
    pre-commit run --all-files clean, including ty.
  • New tests cover each target's capture path, reserved-key clearing across all pieces,
    per-choice finish_reason with n>1, content-filtered responses, "not reported" cases
    (missing/empty/non-string omitted rather than stored as zeros), and a SQLite round-trip.
  • Verified live against Azure OpenAI: finish_reason=stop; a truncated reasoning request →
    status=incomplete + incomplete_reason=max_output_tokens; and a forged caller-supplied
    finish_reason correctly cleared.
  • No doc/notebook changes, so JupyText N/A.

Targets already recorded token usage but discarded why generation stopped,
and dropped usage entirely on content-filtered responses.

Capture the provider's stop reason alongside token usage: finish_reason for
Chat Completions, Completions and LiteLLM; status plus incomplete_reason for
the Responses API. A base no-op hook on OpenAITarget is called from
_handle_content_filter_response, so a filtered response now records the
tokens it consumed instead of returning bare metadata.

These keys are reserved for the provider. construct_response_from_request
merges the request's metadata into every response piece, so all of them are
cleared before a capture writes back the subset its own API reports.
@jsong468 Justin Song (jsong468) self-assigned this Aug 7, 2026
Comment thread pyrit/prompt_target/openai/openai_completion_target.py Outdated
Comment thread pyrit/models/__init__.py
Comment thread pyrit/prompt_target/common/utils.py
Comment thread pyrit/prompt_target/openai/openai_completion_target.py

@jsong468 Justin Song (jsong468) left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! I would just check that metadata is populated correctly when actually sending to these different targets if you haven't already!

Take the reserved keys as explicit keyword parameters instead of an open
mapping, so the clearing loop and the writing loop cannot drift apart: an
unrecognized key is now a type error at the call site rather than a value
that is silently persisted.

Add the two requested inline comments in OpenAICompletionTarget explaining
why the capture is per piece and why it borrows the Chat Completions usage
parser.
The module comment claimed every target clears the reserved keys, which
overstates it: a path that never reaches a provider response captures
nothing and so clears nothing. Name that boundary where the contract is
written down.

Also pin the unreserved-key test to the argument it is really about, and
note in _capture_response_cost that it has to run after usage capture,
which clears the whole token_usage_ prefix.
@varunj-msft
varunj-msft force-pushed the varunj-msft/10481-Capture-API-Response-Metadata branch from 657cff6 to b39485e Compare August 12, 2026 02:45
@varunj-msft
varunj-msft added this pull request to the merge queue Aug 12, 2026
Merged via the queue into microsoft:main with commit e77d8a7 Aug 12, 2026
54 checks passed
@varunj-msft
varunj-msft deleted the varunj-msft/10481-Capture-API-Response-Metadata branch August 12, 2026 04:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants